課程:RTK Query 資料管理 第 2 堂:Query 參數與實作
36Posts API 完整實作
想像你正在開發一個現代化的部落格後台。使用者在左側瀏覽文章列表,點擊其中一項後,右側立即顯示該文章的詳細內容。在傳統的 React 開發中,這通常意味著你需要管理兩個 useEffect、多個 useState(用來存儲列表、單篇文章、Loading 狀態、錯誤訊息),還要擔心使用者快速切換文章時產生的競態條件(Race Condition)。
這正是 RTK Query 大顯身手的時刻。在這一節中,我們將把前面學到的 createApi、動態參數與 skipToken 整合在一起,實作一個完整的 Posts 管理功能。這不只是一個練習,更是你未來在真實專案中處理「列表與詳情」模式的標準藍圖。
建立 Posts API Slice
首先,我們需要定義資料模型與 API Slice。在 TypeScript 中,明確的型別定義是確保開發體驗的第一步。我們將定義一個 Post 介面,並建立兩個 Endpoints:一個用於獲取所有文章,另一個用於根據 ID 獲取特定文章。
1. 定義資料型別與 API 架構
這裡我們使用 JSONPlaceholder 作為模擬後端。請注意 builder.query 的泛型參數是如何定義的:
// src/services/posts.ts
import { createApi, fetchBaseQuery } from '@reduxjs/toolkit/query/react';
// 1. 定義 API 回傳的資料結構
export interface Post {
id: number;
title: string;
body: string;
userId: number;
}
// 2. 建立 API Slice
export const postsApi = createApi({
reducerPath: 'postsApi',
baseQuery: fetchBaseQuery({ baseUrl: 'https://jsonplaceholder.typicode.com/' }),
endpoints: (builder) => ({
// 獲取所有文章:無參數,回傳 Post 陣列
getPosts: builder.query<Post[], void>({
query: () => 'posts?_limit=5', // 限制 5 筆資料方便觀察
}),
// 獲取單筆文章:參數為 id (number),回傳單個 Post
getPostById: builder.query<Post, number>({
query: (id) => `posts/${id}`,
}),
}),
});
// 3. 匯出自動生成的 Hooks
// 命名規則:use + Endpoint名稱 + Query
export const { useGetPostsQuery, useGetPostByIdQuery } = postsApi;
這裡有幾個值得注意的細節:
- 泛型定義:
builder.query<Post[], void>。第一個參數是成功回傳的資料型別,第二個是傳入參數的型別。因為getPosts不需要參數,所以我們填寫void。 - 動態 URL:在
getPostById中,我們利用樣板字串posts/${id}動態生成路徑。這就是動態參數最直觀的應用。 - 自動生成 Hook:RTK Query 會掃描
endpoints並自動產生useGetPostsQuery和useGetPostByIdQuery。這不僅省去了手寫 Hook 的麻煩,還提供了完整的型別補全。
實作 PostManager 元件
接下來,我們要建立一個 React 元件來消費這些資料。這個元件的邏輯如下:
- 進入頁面時,自動抓取文章列表。
- 使用者點擊列表中的某一項。
- 根據點擊的 ID,觸發單篇文章的查詢。
- 如果還沒點擊任何文章,單篇文章的查詢應該處於「停用」狀態。
2. 整合 Hooks 與邏輯控制
我們將使用 skipToken 來優雅地處理「尚未選擇文章」的情境,這比傳入 null 或 undefined 並在 Query 內部做判斷要來得安全且符合 TypeScript 的規範。
// src/components/PostManager.tsx
import React, { useState } from 'react';
import { skipToken } from '@reduxjs/toolkit/query/react';
import { useGetPostsQuery, useGetPostByIdQuery } from '../services/posts';
const PostManager: React.FC = () => {
// 本地狀態:記錄目前選中的文章 ID,初始為 undefined
const [selectedId, setSelectedId] = useState<number | undefined>(undefined);
// 查詢 1:文章列表 (無參數)
const {
data: posts,
isLoading: isListLoading,
isError: isListError
} = useGetPostsQuery();
// 查詢 2:文章詳情
// 如果 selectedId 為 undefined,我們傳入 skipToken 告訴 RTK Query 暫時不要發送請求
const {
data: postDetail,
isFetching: isDetailFetching,
isLoading: isDetailLoading,
error: detailError
} = useGetPostByIdQuery(selectedId ?? skipToken);
// 處理列表讀取狀態
if (isListLoading) return <div>正在載入文章列表...</div>;
if (isListError) return <div>載入列表發生錯誤!</div>;
return (
<div style={{ display: 'flex', gap: '40px', padding: '20px' }}>
{/* 左側:文章列表 */}
<section style={{ flex: 1 }}>
<h2>文章列表</h2>
<ul>
{posts?.map((post) => (
<li
key={post.id}
style={{
cursor: 'pointer',
color: selectedId === post.id ? 'blue' : 'black',
fontWeight: selectedId === post.id ? 'bold' : 'normal',
marginBottom: '8px'
}}
onClick={() => setSelectedId(post.id)}
>
{post.id}. {post.title}
</li>
))}
</ul>
</section>
{/* 右側:文章詳情 */}
<section style={{ flex: 1, borderLeft: '1px solid #ccc', paddingLeft: '20px' }}>
<h2>文章詳情</h2>
{!selectedId && <p>請從左側點選一篇文章以查看細節。</p>}
{/*
當正在從伺服器抓取新資料時顯示提示。
注意:isDetailLoading 僅在「第一次」抓取某 ID 時為 true,
而 isDetailFetching 在每次 ID 改變或重新驗證時皆為 true。
*/}
{isDetailFetching && <p style={{ color: 'orange' }}>正在獲取最新內容...</p>}
{postDetail && !isDetailFetching && (
<div>
<h3>{postDetail.title}</h3>
<p>{postDetail.body}</p>
<hr />
<small>作者 ID: {postDetail.userId}</small>
</div>
)}
{detailError && <p style={{ color: 'red' }}>無法載入詳情。</p>}
</section>
</div>
);
};
export default PostManager;
深入剖析:多個 Hook 的協作機制
在上面的範例中,我們同時呼叫了兩個不同的 Hook。這引出了一個關鍵問題:RTK Query 是如何同時處理這兩個請求而不混淆的?
1. 獨立的狀態生命週期
每一個生成的 Hook 都擁有自己獨立的狀態追蹤器。當 useGetPostsQuery() 執行時,它會在 Redux Store 的 postsApi 命名空間下建立一個專屬於 getPosts(undefined) 的快取條目。與此同時,useGetPostByIdQuery(selectedId) 則會根據 selectedId 的值建立另一個條目。
這意味著:
isListLoading只會因為列表請求而變動。isDetailFetching只會因為詳情請求而變動。- 你不需要在元件層級手動清空之前的
postDetail資料,因為 RTK Query 會根據當前的selectedId自動切換對應的快取資料。
2. skipToken 的妙用
在程式碼中,我們寫了 useGetPostByIdQuery(selectedId ?? skipToken)。這是非常重要的實作細節。
- 如果沒有
skipToken:你可能必須寫成useGetPostByIdQuery(selectedId!)並祈禱selectedId有值,或者在postsApi裡處理參數為undefined的情況。 - 有了
**skipToken**:當**selectedId**為**undefined**時,Hook 內部會進入**uninitialized**狀態。它不會發送任何網路請求,也不會回報錯誤,**data**** 會保持為**undefined**。這讓我們的邏輯變得極其乾淨:**「沒有 ID,就不查詢」。
3. 觀察快取與自動同步
當你在瀏覽器中運行這個範例時,請打開網路面板(Network Tab)觀察:
- 初次點擊文章 1:發送
GET /posts/1。 - 點擊文章 2:發送
GET /posts/2。 - 再次點擊文章 1:奇蹟發生了——網路面板沒有任何新請求!
這是因為 RTK Query 發現 getPostById(1) 的資料已經存在於快取中,且尚未過期。它會立即從快取中回傳 postDetail 給你的元件。這種「去重複(Deduplication)」與「自動快取管理」是 RTK Query 取代 useEffect 的核心競爭力。
動手觀察:isLoading vs. isFetching
為了讓你更深刻理解 RTK Query 的細緻狀態,請嘗試在 PostManager 中觀察 isLoading 與 isFetching 的變化。
- 情境 A:第一次點選文章 5
isLoading:false->true->false
isFetching:false->true->false- 結論:當快取中完全沒有資料時,兩者都會觸發。
- 情境 B:文章 5 已經看過,現在從文章 1 切換回文章 5
isLoading: 始終為false(因為快取裡已經有這份資料了,使用者不需要等待「初次加載」)。
isFetching:false->true->false(RTK Query 會在背後嘗試與伺服器同步,確保資料是最新的,這稱為 Background Revalidation)。- 結論:這讓 UI 可以立即顯示舊資料(不閃爍白屏),同時在角落顯示一個微小的讀取圖示告知使用者正在更新。
這種處理方式極大地提升了使用者體驗(UX),讓應用程式感覺起來非常快速且反應靈敏。
重點回顧與銜接
在本節實作中,我們完成了一個具備動態查詢能力的 Posts API 功能。你應該已經掌握了:
- 如何利用
builder.query<Result, Arg>定義具備強型別參數的 Endpoint。 - 如何匯出並在 React 元件中使用自動生成的 Hooks。
- 利用
skipToken實現條件式查詢,避免無效的 API 呼叫。 - 理解了
isLoading與isFetching在多個查詢共存時的語意差異。
這標誌著我們對 Query Endpoint 基礎知識的掌握已經完備。然而,你有沒有想過:快取中的資料會保存多久?如果我不斷點擊,快取會不會把記憶體吃光?RTK Query 是怎麼知道什麼時候該把舊資料丟掉,什麼時候該保留的?
這正是我們下一個主題的核心。
下一堂預告:快取機制深入
我們將深入探討 RTK Query 的「大腦」——快取生命週期。你會學到「訂閱計數(Subscription Count)」的概念,以及如何透過 keepUnusedDataFor 精確控制資料在記憶體中的存活時間。這將帶你從「會用 Hook」進化到「能優化效能」的進階開發者層次。